Skip to content

Allow appending and then dropping the same number of elements from Stream#608

Open
strikef wants to merge 5 commits intoCopilot-Language:masterfrom
strikef:fix/drop-as-appended
Open

Allow appending and then dropping the same number of elements from Stream#608
strikef wants to merge 5 commits intoCopilot-Language:masterfrom
strikef:fix/drop-as-appended

Conversation

@strikef
Copy link

@strikef strikef commented Mar 14, 2025

https://hackage.haskell.org/package/copilot-language-4.3/docs/Copilot-Language-Operators-Temporal.html#v:drop

The elements must be realizable at the present time to be able to drop elements. For most kinds of streams, you cannot drop elements without prepending an equal or greater number of elements to them first, as it could result in undefined samples.

Current specification states that one may drop the elements from the stream as long as you have appended at least the same number of elements. But the current implementation throws DropIndexOverflow when you try to drop as many elements as appended.

By modifying analyzeDrop we can avoid exception, but copilot would emit incorrect C code from Drop k (Append k s).
This is because Append k s emits an array of length k, but Drop k s gets converted to index k access, which becomes incorrect when drop length and append length are the same.
So I also modified the Reify module s.t. Drop k (Append k s) can be simplified to s. This way we can achieve both correct C codegen & reduced constructor chain.

This modification has been verified using copilot-verifier using several homegrown test cases.

@strikef strikef changed the title Fix/drop as appended Allow appending and then dropping the same number of elements from Stream Mar 16, 2025
@ivanperez-keera
Copy link
Member

Tagging @fdedden and @RyanGlScott so that they take a look.

@RyanGlScott
Copy link
Collaborator

Good catch! Here is a concrete test case that demonstrates the bug in action:

{-# LANGUAGE NoImplicitPrelude #-}
module Main (main) where

import Language.Copilot

spec :: Spec
spec = do
  let s :: Stream Bool
      s = drop 2 ([False, False] ++ true)
  trigger "example" s [arg s]

main :: IO ()
main = interpret 5 spec

Intuitively, I would expect drop 2 ([False, False] ++ true) to be equivalent to true, but in practice, Copilot rejects this with an error:

$ runghc Bug.hs
Bug.hs: Copilot error: Drop index overflow!
CallStack (from HasCallStack):
  error, called at src/Copilot/Language/Error.hs:24:16 in copilot-language-4.3-inplace:Copilot.Language.Error

On the other hand, drop 1 ([False, False] ++ true) does not throw an error. As such, I think your suggested fix of using k > length xs is apt.

(It would be helpful to include something like the program above as a test case.)

Comment on lines 151 to 152
if k == length a
then go s
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you include a comment here saying why we need a special case for k == length a? It wasn't obvious to me until I read the PR description.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relatedly, I wonder if Copilot.Language.Reify if the right place to implement this transformation. Most of the simplifications that are performed over Drop streams are implemented in the drop function itself:

drop :: Typed a => Int -> Stream a -> Stream a
drop 0 s = s
drop _ ( Const j ) = Const j
drop i ( Drop j s ) = Drop (fromIntegral i + j) s
drop i s = Drop (fromIntegral i) s

Perhaps we should implement this transformation as an additional case of drop?

drop i ( Append a _ s ) | i == length a = s

Copy link
Member

@ivanperez-keera ivanperez-keera Mar 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not part of this issue, but that might be a good place to also do something like:

drop i (Op1 op s)     = Op1 op (drop i s)
drop i (Op2 op s1 s2) = Op2 op (drop i s1) (drop i s2)

Copy link
Author

@strikef strikef Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that Reify module isn't the best place to implement this fix. The Temporal module you recommended looks better to me as well.

My initial intent of this change was getting correct C code (which is why I placed this fix at Reify module), and at that time simplification was just a 'nice' side-effect. But now that you mentioned, I don't see any reason to not treat it as simplification.
I'll move my fix to Temporal module and add explanatory comment as well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested

@strikef
Copy link
Author

strikef commented Mar 27, 2025

Good catch! Here is a concrete test case that demonstrates the bug in action:

{-# LANGUAGE NoImplicitPrelude #-}
module Main (main) where

import Language.Copilot

spec :: Spec
spec = do
  let s :: Stream Bool
      s = drop 2 ([False, False] ++ true)
  trigger "example" s [arg s]

main :: IO ()
main = interpret 5 spec

Intuitively, I would expect drop 2 ([False, False] ++ true) to be equivalent to true, but in practice, Copilot rejects this with an error:

$ runghc Bug.hs
Bug.hs: Copilot error: Drop index overflow!
CallStack (from HasCallStack):
  error, called at src/Copilot/Language/Error.hs:24:16 in copilot-language-4.3-inplace:Copilot.Language.Error

On the other hand, drop 1 ([False, False] ++ true) does not throw an error. As such, I think your suggested fix of using k > length xs is apt.

(It would be helpful to include something like the program above as a test case.)

Thanks for the review!
Could you recommend a module to add such tests? I'm not very familiar with the Copilot source tree yet.

@RyanGlScott
Copy link
Collaborator

Could you recommend a module to add such tests?

Since the property of interest is checked in copilot-language, I think it makes sense to add a regression test in the copilot-language test suite. Currently, the copilot-language test suite only contains property-based tests (via QuickCheck), however. We could imagine adding some additional machinery to support a unit test that checks to see if reifying drop 2 [False, False] ++ true throws an exception or not, similar to this unit test in copilot-theorem.

This would take a fair bit of extra work, however, so I'll defer to @ivanperez-keera on whether we want to do all of this as part of this PR.

@@ -1,5 +1,4 @@
-- Copyright © 2011 National Institute of Aerospace / Galois, Inc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line should not be removed, since this change is not related to cleaning up spacing.

(We can remove the line in a separate PR.)

@RyanGlScott
Copy link
Collaborator

Thinking about this some more, I think that changing k >= length xs to k > length xs in Copilot.Language.Analyze is not necessary, and that we should instead make it the sole responsibility of the drop function to ensure that we drop the expected number of elements. To justify this, let me make a handful of claims:

Claim 1: There is an invariant that Drop is always applied to Append

When I say "Drop" and "Append" here, I mean in the sense of the data constructors for Stream in copilot-language, not the user-facing drop or (++) functions. The reason I claim that this invariant exists is because of how Drop is reified:

Drop k e1 -> case e1 of
Append _ _ _ -> do
s <- mkStream refMkId refStreams refMap e1
return $ Core.Drop typeOf (fromIntegral k) s
_ -> impossible "mkExpr" "copilot-language"

Note that reifying Drop k e1 will only succeed if e1 is an Append value. This invariant is not documented in the definition of Stream (perhaps it should be), but it is there all the same. This means that if you manually construct a Drop value (i.e., by directly using the Stream data constructors to build it), then you have to be mindful of this invariant.

Moreover, I think this invariant makes sense to enforce. Drop is a special operation with somewhat non-obvious safety criteria, so it makes sense that we would try to normalize Drop expressions into a form where it is easier to check whether they are valid or not.

Claim 2: The drop function enforces the Drop invariant

Although reification imposes a tight constraint on what forms Drop can have, the user-facing drop function is more permissive. For instance, it is perfectly fine to call the drop function on a constant stream (i.e., a Const):

Because reification would reject Drop _ (Const _), the drop function ensures that dropping elements from a Const will simply evaluate to another Const. This upholds the Drop invariant (vacuously). drop also has a special case for nested applications of Drop:

drop i ( Drop j s ) = Drop (fromIntegral i + j) s

As well as another special case for dropping zero elements, which should succeed for all possible streams:

Claim 3: The Drop invariant should put tight bounds on the number of elements being dropped

Since we are already normalizing Drop expressions, we can be fairly strict about the shapes of these expressions. In particular, we know that backends like copilot-c99 translate Drop expressions to array indexes, so if you have an expression like drop k (xs ++ s), then it is likely that copilot-c99 will translate this into an array access at index i. As such, I think it makes sense for reification to ensure that this array index will always be in bounds.

That brings us to this line of code in copilot-language:

| k >= length xs = throw DropIndexOverflow

I now believe that this check is reasonable, because if xs has length k, then we don't want copilot-c99 to translate this to an array index at index k, as that would be out of bounds. We could check for this on the copilot-c99 side, I suppose, but other backends (e.g., copilot-bluespec) would have the same problem, so I believe it makes sense to enforce this constraint on the copilot-language side.

To summarize: I believe the Drop invariant should require that the number of elements being dropped should be strictly less than the number of elements in the Append. Under this interpretation, the k >= length xs check is reasonable.

Claim 4: The drop function should have a special case for when the number of dropped elements equals the number of Appended elements

If the Drop invariant requires that the number of elements being dropped should be strictly less than the number of elements in the Append, then that raises a question of what to do for things like drop k (xs ++ s), where xs's length is equal to k. The documentation for the drop currently claims that this is permitted, but the stronger Drop invariant that I arrived at in Claim 3 appears to be at odds with this goal.

As I noted in Claim 2, however, these goals aren't at odds after all. This is because the drop function is responsible for enforcing the Drop invariant, so we can uphold the stronger requirements with another special case in drop's implementation:

drop k ( Append xs _ s ) | i == length xs = s

This strips off the outermost Drop if the length equals k, thereby upholding the stronger Drop invariant (again, vacuously). What's more, this is the only change we need to make to copilot-language—no modifications to analysis or reification are required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants